Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 6f96f26736dc428a6f0babe2ca516e3b77c23bc6


Parents : 709576a
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-18T12:41:41-05:00

feat: update lxst dependency to version 0.5.1 and update telephone call policy management

Changes
Diff

diff --git a/meshchatx.rsm b/meshchatx.rsm
index fed2ad1b..c9e63e46 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 47af1b3d..c610e5dc 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -11539,6 +11539,7 @@ class ReticulumMeshChat:
custom_image=custom_image,
is_telemetry_trusted=is_telemetry_trusted,
)
+ self.sync_telephone_call_policy()
return web.json_response({"message": "Contact added"})
@routes.patch("/api/v1/telephone/contacts/{id}")
@@ -11565,12 +11566,14 @@ class ReticulumMeshChat:
clear_image=clear_image,
is_telemetry_trusted=is_telemetry_trusted,
)
+ self.sync_telephone_call_policy()
return web.json_response({"message": "Contact updated"})
@routes.delete("/api/v1/telephone/contacts/{id}")
async def telephone_contacts_delete(request):
contact_id = int(request.match_info["id"])
self.database.contacts.delete_contact(contact_id)
+ self.sync_telephone_call_policy()
return web.json_response({"message": "Contact deleted"})
@routes.get("/api/v1/telephone/contacts/check/{identity_hash}")
@@ -11653,6 +11656,7 @@ class ReticulumMeshChat:
added += 1
except Exception:
skipped += 1
+ self.sync_telephone_call_policy()
return web.json_response(
{"message": "Import complete", "added": added, "skipped": skipped},
)
@@ -16019,6 +16023,7 @@ class ReticulumMeshChat:
local_hash = self.local_lxmf_destination.hash.hex()
self.message_handler.delete_conversation(local_hash, destination_hash)
+ self.sync_telephone_call_policy()
AsyncUtils.run_async(self._broadcast_blocked_destinations())
return web.json_response({"message": "ok"})
@@ -16071,6 +16076,7 @@ class ReticulumMeshChat:
print(f"Failed to unblackhole identity in Reticulum: {e}")
AsyncUtils.run_async(self._broadcast_blocked_destinations())
+ self.sync_telephone_call_policy()
return web.json_response({"message": "ok"})
except Exception as e:
@@ -18345,6 +18351,7 @@ class ReticulumMeshChat:
self.message_router.announce(
destination_hash=self.local_lxmf_destination.hash,
)
+ self.sync_telephone_call_policy()
# update flood protection settings
if "lxmf_flood_protection_enabled" in data:
@@ -18582,6 +18589,7 @@ class ReticulumMeshChat:
self.config.do_not_disturb_enabled.set(
self._parse_bool(data["do_not_disturb_enabled"]),
)
+ self.sync_telephone_call_policy()
if "telephone_enabled" in data:
value = self._parse_bool(data["telephone_enabled"])
@@ -18594,11 +18602,13 @@ class ReticulumMeshChat:
self.telephone_manager.teardown()
elif value and self.telephone_manager:
self.telephone_manager.init_telephone()
+ self.sync_telephone_call_policy()
if "telephone_allow_calls_from_contacts_only" in data:
self.config.telephone_allow_calls_from_contacts_only.set(
self._parse_bool(data["telephone_allow_calls_from_contacts_only"]),
)
+ self.sync_telephone_call_policy()
if "telephone_announce_enabled" in data:
self.config.telephone_announce_enabled.set(
@@ -19535,6 +19545,7 @@ class ReticulumMeshChat:
remote_identity_hash,
lxmf_address=destination_hash_hex,
)
+ self.sync_telephone_call_policy()
# Persist pubkey so outbound LXMF works before any announce.
try:
@@ -20948,6 +20959,81 @@ class ReticulumMeshChat:
except Exception:
return None
+ def _collect_blocked_identity_hashes(self, context=None) -> list:
+ """Identity-hash bytes for LXST set_blocked from the block list."""
+ ctx = context or self.current_context
+ out = []
+ seen = set()
+ if not ctx or not ctx.database:
+ return out
+ try:
+ blocked = ctx.database.misc.get_blocked_destinations()
+ except Exception:
+ return out
+
+ for row in blocked or []:
+ dest_hex = row.get("destination_hash") if isinstance(row, dict) else None
+ if not dest_hex:
+ continue
+ candidates = [dest_hex]
+ try:
+ announce = ctx.database.announces.get_announce_by_hash(dest_hex)
+ if announce and announce.get("identity_hash"):
+ candidates.append(announce["identity_hash"])
+ except Exception:
+ pass
+ for candidate in candidates:
+ try:
+ raw = bytes.fromhex(str(candidate))
+ except Exception:
+ continue
+ if len(raw) != RNS.Reticulum.TRUNCATED_HASHLENGTH // 8:
+ continue
+ if raw in seen:
+ continue
+ seen.add(raw)
+ out.append(raw)
+ return out
+
+ def sync_telephone_call_policy(self, context=None):
+ """Push contacts-only / DND / block policy into LXST Telephone.set_allowed.
+
+ This rejects unauthorized callers before RINGING instead of relying only
+ on a delayed hangup after the ringing callback.
+ """
+ ctx = context or self.current_context
+ if not ctx or not getattr(ctx, "telephone_manager", None):
+ return
+
+ def allowed(identity_hash: bytes, policy_ctx=ctx) -> bool:
+ if not isinstance(identity_hash, (bytes, bytearray)):
+ return False
+ caller_hex = bytes(identity_hash).hex()
+ try:
+ if policy_ctx.config.do_not_disturb_enabled.get():
+ return False
+ if self.is_destination_blocked(caller_hex, context=policy_ctx):
+ return False
+ if (
+ policy_ctx.config.telephone_allow_calls_from_contacts_only.get()
+ or policy_ctx.config.block_all_from_strangers.get()
+ ) and not self._is_contact(caller_hex, context=policy_ctx):
+ return False
+ return True
+ except Exception as e:
+ print(f"sync_telephone_call_policy allowed() error: {e}")
+ return False
+
+ try:
+ ctx.telephone_manager.set_call_policy(
+ allowed_fn=allowed,
+ blocked_identity_hashes=self._collect_blocked_identity_hashes(
+ context=ctx,
+ ),
+ )
+ except Exception as e:
+ print(f"sync_telephone_call_policy failed: {e}")
+
def _is_contact(self, source_hash: str, context=None) -> bool:
return self._resolve_contact_for_hash(source_hash, context=context) is not None
@@ -21131,6 +21217,7 @@ class ReticulumMeshChat:
self._lxmf_reticulum_enforce_block(destination_hash)
self._delete_contact_and_stamp_ticket(destination_hash, context=ctx)
AsyncUtils.run_async(self._broadcast_blocked_destinations())
+ self.sync_telephone_call_policy(context=ctx)
def check_spam_keywords(self, title: str, content: str, context=None) -> bool:
"""Return whether title/content match configured spam keywords."""

diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 87feb6bc..9a289bd4 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -405,6 +405,8 @@ class IdentityContext:
# Only initialize telephone hardware/profile if not in emergency mode
if not getattr(self.app, "emergency", False):
self.telephone_manager.init_telephone()
+ with contextlib.suppress(Exception):
+ self.app.sync_telephone_call_policy(context=self)
self.voicemail_manager = VoicemailManager(
db=self.database,

diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py
index 4ab7739b..4676f979 100644
--- a/meshchatx/src/backend/telephone_manager.py
+++ b/meshchatx/src/backend/telephone_manager.py
@@ -90,6 +90,8 @@ class TelephoneManager:
self._status_poll_interval_s = 0.1
self.is_voicemail_session_active = False
self.preferred_profile_id = None
+ self._caller_allowed = None
+ self._blocked_identity_hashes = None
@property
def is_recording(self):
@@ -166,7 +168,10 @@ class TelephoneManager:
if self.config_manager and not self.config_manager.telephone_enabled.get():
return
- self.telephone = Telephone(self.identity)
+ # Never enable LXST auto_answer. MeshChatX answers only via explicit
+ # user action or the separate voicemail timer after RINGING.
+ self.telephone = Telephone(self.identity, auto_answer=None)
+ self.telephone.auto_answer = None
# Disable busy tone played on caller side when remote side rejects, or doesn't answer
self.telephone.set_busy_tone_time(0)
# Increase connection timeout for slower networks
@@ -176,10 +181,71 @@ class TelephoneManager:
# preferred profile and pass it into telephone.call() on outbound dial.
self.preferred_profile_id = self.resolve_audio_profile_id()
+ self._install_link_table_cleanup()
+ self.refresh_call_policy()
+
self.telephone.set_ringing_callback(self.on_telephone_ringing)
self.telephone.set_established_callback(self.on_telephone_call_established)
self.telephone.set_ended_callback(self.on_telephone_call_ended)
+ def set_call_policy(self, allowed_fn=None, blocked_identity_hashes=None):
+ """Install LXST-level allow/block checks used before RINGING.
+
+ allowed_fn receives the caller identity hash as bytes and must return bool.
+ blocked_identity_hashes is an optional iterable of identity hash bytes.
+ """
+ self._caller_allowed = allowed_fn
+ if blocked_identity_hashes is None:
+ self._blocked_identity_hashes = None
+ else:
+ self._blocked_identity_hashes = [
+ bytes(h) for h in blocked_identity_hashes if h is not None
+ ]
+ self.refresh_call_policy()
+
+ def refresh_call_policy(self):
+ """Push current policy into the live Telephone instance."""
+ if self.telephone is None:
+ return
+
+ self.telephone.auto_answer = None
+
+ if self._blocked_identity_hashes is not None:
+ self.telephone.set_blocked(list(self._blocked_identity_hashes))
+ else:
+ self.telephone.set_blocked(None)
+
+ if callable(self._caller_allowed):
+ self.telephone.set_allowed(self._caller_allowed)
+ else:
+ # Fail closed until MeshChatX installs sync_telephone_call_policy.
+ self.telephone.set_allowed(Telephone.ALLOW_NONE)
+
+ def _install_link_table_cleanup(self):
+ """Ensure closed inbound links are removed from Telephone.links.
+
+ Older LXST builds never popped self.links on close. Patch the bound
+ handler so MeshChatX stays safe even before the LXST bump is installed.
+ """
+ phone = self.telephone
+ if phone is None:
+ return
+ previous = getattr(phone, "_Telephone__link_closed", None)
+ if previous is None or getattr(previous, "_meshchatx_link_cleanup", False) is True:
+ return
+
+ def _link_closed(link, previous=previous, phone=phone):
+ try:
+ previous(link)
+ finally:
+ link_id = getattr(link, "link_id", None)
+ if link_id is not None:
+ with contextlib.suppress(Exception):
+ phone.links.pop(link_id, None)
+
+ _link_closed._meshchatx_link_cleanup = True
+ phone._Telephone__link_closed = _link_closed
+
def teardown(self):
if self.telephone is not None:
self.telephone.teardown()

diff --git a/pyproject.toml b/pyproject.toml
index 0588b947..54b75411 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,7 +32,7 @@ dependencies = [
"pycparser>=3.0",
"audioop-lts>=0.2.2; python_version >= '3.13'",
"ply>=3.11,<4.0",
- "lxst>=0.4.8",
+ "lxst>=0.5.1",
"miniaudio (>=1.70,<2.0)",
"cbor2>=6.1.1",
"wasmtime>=28.0.0",

diff --git a/requirements.txt b/requirements.txt
index f8837f24..cbc68872 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -510,8 +510,7 @@ lxmf==1.0.1 \
# via
# lxst
# reticulum-meshchatx
-lxst==0.4.8 \
- --hash=sha256:4cdf9c0b5e7fed85805c9716491b0eb9b753c43d15e6c6d7c5c53ebe2a7489b7
+lxst==0.5.1
# via reticulum-meshchatx
miniaudio==1.71 \
--hash=sha256:06222d80b057ca4beccb6f97a134c2c2bf646ef7890e1759cfc09db7eecec44d \

diff --git a/tests/backend/test_maintenance.py b/tests/backend/test_maintenance.py
index 432feb17..ebb13ced 100644
--- a/tests/backend/test_maintenance.py
+++ b/tests/backend/test_maintenance.py
@@ -17,10 +17,11 @@ class TestMaintenance(unittest.TestCase):
def test_delete_all_lxmf_messages(self):
self.messages_dao.delete_all_lxmf_messages()
- self.assertEqual(self.provider.execute.call_count, 2)
+ self.assertEqual(self.provider.execute.call_count, 3)
calls = self.provider.execute.call_args_list
self.assertIn("DELETE FROM lxmf_messages", calls[0][0][0])
self.assertIn("DELETE FROM lxmf_conversation_read_state", calls[1][0][0])
+ self.assertIn("DELETE FROM lxmf_conversation_summaries", calls[2][0][0])
def test_delete_all_announces(self):
# Test without aspect
@@ -64,9 +65,13 @@ class TestMaintenance(unittest.TestCase):
}
self.messages_dao.upsert_lxmf_message(msg_data)
self.provider.execute.assert_called()
- args, _ = self.provider.execute.call_args
- self.assertIn("INSERT INTO lxmf_messages", args[0])
- self.assertIn("ON CONFLICT(hash) DO UPDATE SET", args[0])
+ message_inserts = [
+ call[0][0]
+ for call in self.provider.execute.call_args_list
+ if "INSERT INTO lxmf_messages" in call[0][0]
+ ]
+ self.assertEqual(len(message_inserts), 1)
+ self.assertIn("ON CONFLICT(hash) DO UPDATE SET", message_inserts[0])
if __name__ == "__main__":

diff --git a/tests/backend/test_message_dao_extended.py b/tests/backend/test_message_dao_extended.py
index 21fbf951..f7efe59f 100644
--- a/tests/backend/test_message_dao_extended.py
+++ b/tests/backend/test_message_dao_extended.py
@@ -204,7 +204,11 @@ def test_delete_lxmf_messages_by_hashes(message_dao, mock_provider):
def test_delete_all_lxmf_messages(message_dao, mock_provider):
message_dao.delete_all_lxmf_messages()
- assert mock_provider.execute.call_count == 2
+ assert mock_provider.execute.call_count == 3
+ calls = [call[0][0] for call in mock_provider.execute.call_args_list]
+ assert any("DELETE FROM lxmf_messages" in q for q in calls)
+ assert any("DELETE FROM lxmf_conversation_read_state" in q for q in calls)
+ assert any("DELETE FROM lxmf_conversation_summaries" in q for q in calls)
def test_get_conversation_messages(message_dao, mock_provider):

diff --git a/tests/backend/test_telephone_call_policy.py b/tests/backend/test_telephone_call_policy.py
new file mode 100644
index 00000000..67228313
--- /dev/null
+++ b/tests/backend/test_telephone_call_policy.py
@@ -0,0 +1,106 @@
+# SPDX-License-Identifier: 0BSD
+
+"""TelephoneManager LXST call policy: set_allowed, no auto_answer, link cleanup."""
+
+from unittest.mock import MagicMock, patch
+
+from meshchatx.src.backend.telephone_manager import TelephoneManager
+
+
+@patch("meshchatx.src.backend.telephone_manager.Telephone")
+def test_init_telephone_disables_auto_answer_and_applies_policy(mock_tel_class, tmp_path):
+ storage_dir = tmp_path / "tel"
+ storage_dir.mkdir()
+ cfg = MagicMock()
+ cfg.telephone_enabled.get.return_value = True
+ cfg.telephone_audio_profile_id.get.return_value = 64
+
+ phone = MagicMock()
+ phone.links = {}
+ phone._Telephone__link_closed = MagicMock()
+ mock_tel_class.return_value = phone
+
+ tm = TelephoneManager(MagicMock(), config_manager=cfg, storage_dir=str(storage_dir))
+ tm.set_call_policy(
+ allowed_fn=lambda _h: True,
+ blocked_identity_hashes=[b"\x11" * 16],
+ )
+ tm.init_telephone()
+
+ assert mock_tel_class.call_args.kwargs.get("auto_answer") is None
+ assert phone.auto_answer is None
+ phone.set_allowed.assert_called()
+ phone.set_blocked.assert_called()
+ assert callable(phone.set_allowed.call_args[0][0])
+
+
+@patch("meshchatx.src.backend.telephone_manager.Telephone")
+def test_refresh_call_policy_contacts_only_callback(mock_tel_class, tmp_path):
+ storage_dir = tmp_path / "tel"
+ storage_dir.mkdir()
+ cfg = MagicMock()
+ cfg.telephone_enabled.get.return_value = True
+ cfg.telephone_audio_profile_id.get.return_value = 64
+ phone = MagicMock()
+ phone.links = {}
+ phone._Telephone__link_closed = MagicMock()
+ mock_tel_class.return_value = phone
+
+ tm = TelephoneManager(MagicMock(), config_manager=cfg, storage_dir=str(storage_dir))
+ tm.init_telephone()
+
+ friend = b"\xaa" * 16
+ stranger = b"\xbb" * 16
+
+ def allowed(identity_hash: bytes) -> bool:
+ return identity_hash == friend
+
+ tm.set_call_policy(allowed_fn=allowed)
+ fn = phone.set_allowed.call_args[0][0]
+ assert fn(friend) is True
+ assert fn(stranger) is False
+ assert phone.auto_answer is None
+
+
+def test_install_link_cleanup_pops_closed_links():
+ tm = TelephoneManager(identity=MagicMock())
+ phone = MagicMock()
+ phone.links = {"lid": object()}
+ previous = MagicMock()
+ phone._Telephone__link_closed = previous
+ tm.telephone = phone
+ tm._install_link_table_cleanup()
+
+ link = MagicMock()
+ link.link_id = "lid"
+ phone._Telephone__link_closed(link)
+
+ previous.assert_called_once_with(link)
+ assert "lid" not in phone.links
+
+
+def test_sync_telephone_call_policy_wires_allowed_fn():
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ ctx = MagicMock()
+ tm = MagicMock()
+ ctx.telephone_manager = tm
+ ctx.config.do_not_disturb_enabled.get.return_value = False
+ ctx.config.telephone_allow_calls_from_contacts_only.get.return_value = True
+ ctx.config.block_all_from_strangers.get.return_value = False
+ ctx.database.misc.get_blocked_destinations.return_value = []
+ app.current_context = ctx
+ app.is_destination_blocked = MagicMock(return_value=False)
+ app._is_contact = MagicMock(side_effect=lambda h, context=None: h == "aa" * 16)
+
+ app.sync_telephone_call_policy(context=ctx)
+
+ tm.set_call_policy.assert_called_once()
+ kwargs = tm.set_call_policy.call_args.kwargs
+ allowed = kwargs["allowed_fn"]
+ assert allowed(bytes.fromhex("aa" * 16)) is True
+ assert allowed(bytes.fromhex("bb" * 16)) is False
+
+ ctx.config.do_not_disturb_enabled.get.return_value = True
+ assert allowed(bytes.fromhex("aa" * 16)) is False

diff --git a/tests/backend/test_telephone_initiation.py b/tests/backend/test_telephone_initiation.py
index d06c188c..8bf9fbfa 100644
--- a/tests/backend/test_telephone_initiation.py
+++ b/tests/backend/test_telephone_initiation.py
@@ -516,7 +516,13 @@ def test_init_telephone_creates_when_enabled(mock_tel_class, tmp_path):
storage_dir.mkdir()
cfg = MagicMock()
cfg.telephone_enabled.get.return_value = True
+ cfg.telephone_audio_profile_id.get.return_value = 64
+ phone = MagicMock()
+ phone.links = {}
+ phone._Telephone__link_closed = MagicMock()
+ mock_tel_class.return_value = phone
tm = TelephoneManager(MagicMock(), config_manager=cfg, storage_dir=str(storage_dir))
tm.init_telephone()
assert tm.telephone is not None
mock_tel_class.assert_called_once()
+ assert mock_tel_class.call_args.kwargs.get("auto_answer") is None

diff --git a/uv.lock b/uv.lock
index fd259497..de6dd77c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1952,7 +1952,7 @@ requires-dist = [
{ name = "cbor2", specifier = ">=6.1.1" },
{ name = "cryptography", specifier = ">=49.0.0,<50.0.0" },
{ name = "lxmf", specifier = ">=1.0.1" },
- { name = "lxst", specifier = ">=0.4.8" },
+ { name = "lxst", specifier = ">=0.5.1" },
{ name = "miniaudio", specifier = ">=1.70,<2.0" },
{ name = "ply", specifier = ">=3.11,<4.0" },
{ name = "psutil", specifier = ">=7.2.2" },


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────